Skip to content

Seatbelt: fall back to policy-allowed cwd to avoid getcwd warnings - #688

Open
richiemsft wants to merge 2 commits into
mainfrom
user/saulg/fix-611-seatbelt
Open

Seatbelt: fall back to policy-allowed cwd to avoid getcwd warnings#688
richiemsft wants to merge 2 commits into
mainfrom
user/saulg/fix-611-seatbelt

Conversation

@richiemsft

@richiemsft richiemsft commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

📖 Description

When an explicit process.cwd (or the inherited host cwd) is not readable under the deny-by-default Seatbelt profile, the child shell's startup getcwd() walk fails and leaks noisy "cannot access parent directories" warnings from bash's shell-init / job-working-directory onto stderr.

resolve_working_directory now honors an explicit working directory only when it is readable under the filesystem policy (within a readwrite/readonly path and not within a denied path, matched component-wise). Otherwise it launches from a policy-allowed directory (first readwrite, else first readonly, else /) and logs an informational diagnostic to the mxc log. The command still runs unchanged; only the launch directory changes, never the granted filesystem access.

Adds unit tests for the allow/deny/fallback/tilde/component-boundary cases and an end-to-end characterization test asserting an out-of-policy cwd produces no getcwd noise while the command still succeeds. Updates the Seatbelt backend doc.

🔗 References

Resolves the Mac issue from #611

🔍 Validation

Validated locally on a Mac. All green

✅ Checklist

📋 Issue Type

  • Bug fix
  • Feature
  • Task

GitHub Actions runs the PR validation build automatically. The ADO pipeline
(MXC-PR-Build) is the Azure version of the PR pipeline, kept in parity with the GitHub
Actions build; it runs on merge to main, and Microsoft reviewers with write access can trigger it
on a PR with /azp run. See docs/pull-requests.md.

If the dependency-feed-check check fails on a new dependency, the crate must be added to
the feed before the PR can pass. See docs/pull-requests.md
for the steps.

Microsoft Reviewers: Open in CodeFlow

@richiemsft
richiemsft requested a review from a team as a code owner July 27, 2026 20:26
Copilot AI review requested due to automatic review settings July 27, 2026 20:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@richiemsft

Copy link
Copy Markdown
Contributor Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

When an explicit process.cwd (or the inherited host cwd) is not readable
under the deny-by-default Seatbelt profile, the child shell's startup
getcwd() walk fails and leaks noisy "cannot access parent directories"
warnings from bash's shell-init / job-working-directory onto stderr.

resolve_working_directory now honors an explicit working directory only
when it is readable under the filesystem policy (within a
readwrite/readonly path and not within a denied path, matched
component-wise). Otherwise it launches from a policy-allowed directory
(first readwrite, else first readonly, else /) and logs an informational
diagnostic to the mxc log. The command still runs unchanged; only the
launch directory changes, never the granted filesystem access.

Adds unit tests for the allow/deny/fallback/tilde/component-boundary
cases and an end-to-end characterization test asserting an out-of-policy
cwd produces no getcwd noise while the command still succeeds. Updates
the Seatbelt backend doc.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e4e66e58-7a38-4be3-ab78-32fa15b726ca
Copilot AI review requested due to automatic review settings July 27, 2026 20:32
@richiemsft
richiemsft force-pushed the user/saulg/fix-611-seatbelt branch from 89b985e to 8f21b13 Compare July 27, 2026 20:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

src/backends/seatbelt/common/src/seatbelt_runner.rs:722

  • The first policy path is not guaranteed to be usable. Missing paths are only warned about (config_parser.rs:358-373), and a broader deny can cover an allow (for example, allow /work/sub plus deny /work), so this can select a nonexistent or denied cwd and make Command::spawn fail instead of trying a valid readonly path or /. Select the first expanded, existing directory that also passes the deny check.
    let default = request
        .policy
        .readwrite_paths
        .first()
        .or_else(|| request.policy.readonly_paths.first())

Comment thread src/backends/seatbelt/common/src/seatbelt_runner.rs Outdated
Comment thread src/backends/seatbelt/common/src/seatbelt_runner.rs
…check

Addresses PR review feedback:

- A raw `profileOverride` replaces the generated profile, so the
  readwrite/readonly/denied fields are not applied. resolve_working_directory
  now preserves an explicit cwd unchanged when an override is present instead
  of consulting the ignored policy fields (which could wrongly rewrite the cwd
  to the fallback and break relative commands).

- is_working_directory_allowed now lexically normalizes (folds `.`/`..`,
  collapses repeated slashes) the requested cwd and the policy roots before the
  containment test, so `/work/../private` is no longer classified as within an
  allowed `/work`.

Adds unit tests for both cases plus normalize_path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e4e66e58-7a38-4be3-ab78-32fa15b726ca
Copilot AI review requested due to automatic review settings July 28, 2026 16:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

src/backends/seatbelt/common/src/seatbelt_runner.rs:788

  • Lexical normalization does not compare filesystem aliases. On macOS, for example, a policy may allow /tmp/work while a caller supplies the equivalent canonical cwd /private/tmp/work/sub; this check classifies that readable cwd as outside the policy and replaces it with /tmp/work, so commands using relative paths run from the wrong directory. Resolve both the cwd and policy paths through symlinks (including existing ancestors for absent tails) before applying the component-wise containment check.
/// Lexically normalize an absolute path: collapse repeated slashes, drop `.`
/// components, and resolve `..` by popping the previous component (never past
/// the root). Purely lexical — it does not resolve symlinks or touch the
/// filesystem — which is sufficient to fold the `..` a caller may embed in a
/// requested cwd before the containment check.

src/backends/seatbelt/common/src/seatbelt_runner.rs:723

  • The fallback is not guaranteed to be policy-allowed: it blindly selects the first read-write/read-only entry. A valid policy such as readwritePaths: ["/work/private"] with deniedPaths: ["/work"] leaves that first entry denied by Seatbelt's trailing deny rule, so an out-of-policy cwd still launches in an unreadable directory and retains the getcwd warnings. The selected candidate should be checked against deny precedence (and skipped when unusable) before falling back to /.

This issue also appears on line 784 of the same file.

/// Pick a directory the Seatbelt profile is guaranteed to allow: the first
/// readwrite path, else the first readonly path, else `/`.
fn policy_fallback_directory(request: &ExecutionRequest) -> String {

docs/macos-support/seatbelt-backend.md:236

  • This description contradicts the implemented profileOverride behavior: with an override, an explicit cwd is always honored because the filesystem fields are ignored, even when it is outside every read/write path. Document that exception so users of raw profiles do not expect the cwd to be replaced.
If `process.cwd` is provided it is honored **only when it is readable under the
filesystem policy** — i.e. within a `readwritePaths`/`readonlyPaths` entry and
not within a `deniedPaths` entry. If `process.cwd` is omitted, or points at a
directory the policy does not allow, it resolves to `readwritePaths[0]`, else

/// ordering). Policy paths are tilde-expanded exactly as the profile builder
/// expands them so the comparison sees the same absolute paths the profile
/// grants. Matching is component-wise, so `/data` never matches `/database`.
fn is_working_directory_allowed(request: &ExecutionRequest, dir: &str) -> bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relative working directories are incorrectly replaced by the fallback

process.cwd currently accepts relative paths, which Command::current_dir resolves against the executor's host cwd. This check instead compares the unresolved relative string directly with absolute policy paths, so it always considers the path unreadable.

For example:

{
  "process": {
    "cwd": "src"
  },
  "filesystem": {
    "readwritePaths": ["/repo"]
  }
}

If the executor is running from /repo, the intended child cwd is /repo/src. The new check compares src with /repo, fails, and launches the child from the fallback /repo. Commands using cwd-relative files then run from the wrong directory.

Similarly, cwd: "." can be replaced by the first read-write path even when the inherited cwd is already inside an allowed read-only path.

Could we resolve relative cwd values against std::env::current_dir() before normalization and containment checking? Please also add regression tests for cwd: "." and cwd: "src" when their resolved absolute paths are policy-readable.

/// grants. Matching is component-wise, so `/data` never matches `/database`.
fn is_working_directory_allowed(request: &ExecutionRequest, dir: &str) -> bool {
// Expand `~` exactly as the profile builder does, then lexically fold `.` /
// `..` so the containment test compares the path the kernel actually

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cwd readability check does not match the generated Seatbelt profile

is_working_directory_allowed only considers readwritePaths and readonlyPaths, but the generated Seatbelt profile also grants read access to several system locations unconditionally, including:

  • /bin
  • /usr/bin
  • /usr/lib
  • /usr/share
  • /System
  • /Library
  • selected paths under /private

This means an explicit cwd such as /usr/share or /Library/Application Support may be readable under the actual profile, but this check still rejects it and silently launches from the first configured read-write or read-only path instead.

That changes command behavior unnecessarily—for example, pwd, relative arguments, and tools that discover configuration from the cwd will see a different directory even though the requested directory was already accessible.

Could we derive this decision from the same baseline path data used by profile_builder rather than maintaining a narrower second definition of readability? A regression test using a baseline-readable cwd such as /usr/share would also help prevent the two implementations from drifting.

To be fair, filesystem policy being deny-by-default means that we really need a way to 'include' the directories that are accessible by default on a given container platform, so that a) policy authors don't have to list them explicitly and b) the policy is still accurate. The policy 'spec' does allude to this, but we've not implemented it yet.

fn is_working_directory_allowed(request: &ExecutionRequest, dir: &str) -> bool {
// Expand `~` exactly as the profile builder does, then lexically fold `.` /
// `..` so the containment test compares the path the kernel actually
// resolves. Without this, `/work/../private` would spuriously match an

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escape the requested cwd before writing it to the diagnostic log

The fallback message interpolates request.working_directory directly into a line-oriented log entry. Unix paths can contain control characters, including newlines, so a value such as:

/outside-policy
ERROR: sandbox initialization failed

would create what appears to be a separate, forged log entry. This does not cross the sandbox boundary because the cwd comes from the caller's configuration, but it can make diagnostics misleading or difficult to parse reliably.

Could we log the path using escaped/debug formatting, such as {:?} or escape_default(), so each fallback remains a single well-formed log line?

// Expand `~` exactly as the profile builder does, then lexically fold `.` /
// `..` so the containment test compares the path the kernel actually
// resolves. Without this, `/work/../private` would spuriously match an
// allowed `/work` even though it resolves outside it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add coverage for the new fallback diagnostic

The new fallback behavior includes an operator-facing log message explaining why the requested cwd was replaced, but the tests only verify the returned directory. They do not verify that the diagnostic was emitted or that it identifies the requested and fallback paths.

Since these tests already use a buffered Logger, could we add assertions that:

  • A rejected cwd logs both the requested directory and the selected fallback.
  • An allowed cwd does not emit a fallback diagnostic.
  • The logged paths are escaped so control characters cannot create additional log lines.

This would protect the primary diagnostic users have when a command unexpectedly starts in a different directory.

@microsoft-github-policy-service microsoft-github-policy-service Bot added the Needs-Author-Feedback Issue needs attention from issue or PR author label Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs-Author-Feedback Issue needs attention from issue or PR author

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants